D:\a\cssh-rs\cssh-rs\xtask\src\inject_agent_token.rs
Line | Count | Source |
1 | | //! Paseo agent GitHub auth injection. |
2 | | //! |
3 | | //! A paseo-spawned agent would otherwise inherit the user's full `gh` |
4 | | //! login - including classic scopes like `repo` that allow deleting |
5 | | //! repositories or force-pushing to `main`. This module is the |
6 | | //! counterpart of that risk: on worktree creation, it writes a |
7 | | //! per-worktree `.claude/settings.local.json` whose `env` map carries |
8 | | //! a fine-grained PAT supplied by the contributor. Claude Code |
9 | | //! injects that `env` into the agent process, and `gh` honors |
10 | | //! `GH_TOKEN` over the keyring, so the agent ends up acting as the |
11 | | //! scoped PAT while the contributor's own `gh` session outside paseo |
12 | | //! is unaffected. |
13 | | //! |
14 | | //! The token source is `<source-checkout>/.paseo/gh-token` - a |
15 | | //! gitignored file the contributor creates once per clone. The |
16 | | //! source checkout path is taken from the `PASEO_SOURCE_CHECKOUT_PATH` |
17 | | //! environment variable paseo sets when running setup steps; if that |
18 | | //! variable is absent, the current directory is used instead, which |
19 | | //! covers manual `cargo xtask inject-agent-token` invocations from |
20 | | //! the repo root. |
21 | | //! |
22 | | //! If the token file is missing the subcommand is a silent no-op |
23 | | //! (with an informational log line). Fine-grained PATs |
24 | | //! (`github_pat_...`) are recommended because they can be restricted |
25 | | //! to specific repositories and to a subset of repository permissions. |
26 | | //! Classic (`ghp_...`) and OAuth (`gho_...`) tokens are accepted to |
27 | | //! avoid hard-blocking contributors who only have those, but each |
28 | | //! triggers a warning log line since they cannot be scoped tightly |
29 | | //! enough to preserve the least-privilege property. Any other content |
30 | | //! is rejected so we never inject arbitrary text as a token. |
31 | | |
32 | | use std::path::{Path, PathBuf}; |
33 | | |
34 | | use anyhow::{bail, Context, Result}; |
35 | | |
36 | | /// Prefix for a fine-grained personal access token. This is the |
37 | | /// recommended token shape because it can be restricted to specific |
38 | | /// repositories and to a subset of repository permissions. |
39 | | const FINE_GRAINED_PREFIX: &str = "github_pat_"; |
40 | | |
41 | | /// Prefix for a classic personal access token. Accepted to avoid |
42 | | /// hard-blocking contributors who only have a classic token, but |
43 | | /// flagged with a warning since classic tokens cannot be scoped to |
44 | | /// specific repositories or to a subset of repository permissions. |
45 | | const CLASSIC_PREFIX: &str = "ghp_"; |
46 | | |
47 | | /// Prefix for an OAuth user-to-server token. Accepted with the same |
48 | | /// caveat as [`CLASSIC_PREFIX`]. |
49 | | const OAUTH_PREFIX: &str = "gho_"; |
50 | | |
51 | | /// Relative path inside the source checkout where the contributor |
52 | | /// stores their GitHub token. |
53 | | const TOKEN_FILE_REL_PATH: &str = ".paseo/gh-token"; |
54 | | |
55 | | /// Relative path inside the worktree where Claude Code reads local, |
56 | | /// uncommitted per-project settings. |
57 | | const SETTINGS_FILE_REL_PATH: &str = ".claude/settings.local.json"; |
58 | | |
59 | | /// All side-effecting operations performed by this subcommand. |
60 | | /// |
61 | | /// Implement with mocks in tests to achieve zero filesystem, |
62 | | /// environment, or process side-effects. |
63 | | pub trait InjectAgentTokenSystem { |
64 | | /// Look up an environment variable. |
65 | | /// |
66 | | /// # Arguments |
67 | | /// |
68 | | /// * `key` - Environment variable name. |
69 | | /// |
70 | | /// # Returns |
71 | | /// |
72 | | /// `Some(value)` when the variable is set and non-empty, |
73 | | /// `None` otherwise. |
74 | | fn env_var(&self, key: &str) -> Option<String>; |
75 | | |
76 | | /// Return the current working directory. |
77 | | /// |
78 | | /// # Errors |
79 | | /// |
80 | | /// Returns an error if the current directory cannot be |
81 | | /// determined. |
82 | | fn current_dir(&self) -> Result<PathBuf>; |
83 | | |
84 | | /// Read the token file at `path`. |
85 | | /// |
86 | | /// # Arguments |
87 | | /// |
88 | | /// * `path` - Absolute or worktree-relative path to the token |
89 | | /// file. |
90 | | /// |
91 | | /// # Returns |
92 | | /// |
93 | | /// `Ok(Some(contents))` when the file exists and is readable, |
94 | | /// `Ok(None)` when it does not exist (the subcommand treats |
95 | | /// this as a no-op). |
96 | | /// |
97 | | /// # Errors |
98 | | /// |
99 | | /// Returns an error for filesystem failures other than |
100 | | /// "not found" (for example, permission denied). |
101 | | fn read_token_file(&self, path: &Path) -> Result<Option<String>>; |
102 | | |
103 | | /// Write `contents` to the settings file at `path`, creating |
104 | | /// any missing parent directories. |
105 | | /// |
106 | | /// # Arguments |
107 | | /// |
108 | | /// * `path` - Target path for the settings file. |
109 | | /// * `contents` - Full file contents to write. |
110 | | /// |
111 | | /// # Errors |
112 | | /// |
113 | | /// Returns an error if directory creation or the write fails. |
114 | | fn write_settings(&self, path: &Path, contents: &str) -> Result<()>; |
115 | | } |
116 | | |
117 | | /// Production implementation of [`InjectAgentTokenSystem`]. |
118 | | pub struct RealSystem; |
119 | | |
120 | | #[cfg_attr(coverage_nightly, coverage(off))] |
121 | | impl InjectAgentTokenSystem for RealSystem { |
122 | | fn env_var(&self, key: &str) -> Option<String> { |
123 | | std::env::var(key).ok().filter(|v| !v.is_empty()) |
124 | | } |
125 | | |
126 | | fn current_dir(&self) -> Result<PathBuf> { |
127 | | std::env::current_dir().context("failed to resolve current directory") |
128 | | } |
129 | | |
130 | | fn read_token_file(&self, path: &Path) -> Result<Option<String>> { |
131 | | match std::fs::read_to_string(path) { |
132 | | Ok(contents) => Ok(Some(contents)), |
133 | | Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), |
134 | | Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())), |
135 | | } |
136 | | } |
137 | | |
138 | | fn write_settings(&self, path: &Path, contents: &str) -> Result<()> { |
139 | | if let Some(parent) = path.parent() { |
140 | | std::fs::create_dir_all(parent) |
141 | | .with_context(|| format!("failed to create {}", parent.display()))?; |
142 | | } |
143 | | std::fs::write(path, contents) |
144 | | .with_context(|| format!("failed to write {}", path.display()))?; |
145 | | Ok(()) |
146 | | } |
147 | | } |
148 | | |
149 | | /// Build the JSON body written to `.claude/settings.local.json`. |
150 | | /// |
151 | | /// Caller-enforced invariant: `token` contains only bytes in |
152 | | /// `[A-Za-z0-9_]`. That alphabet has no characters that require JSON |
153 | | /// escaping, which is what lets this function skip a general-purpose |
154 | | /// JSON encoder without risking injection. The invariant is enforced |
155 | | /// by [`is_in_token_alphabet`] inside [`inject_agent_token`]. |
156 | | /// |
157 | | /// # Arguments |
158 | | /// |
159 | | /// * `token` - GitHub token, already validated and trimmed. |
160 | | /// |
161 | | /// # Returns |
162 | | /// |
163 | | /// A pretty-printed JSON document terminated with a newline. |
164 | 4 | fn build_settings_body(token: &str) -> String { |
165 | 4 | format!( |
166 | | "{{\n \"env\": {{\n \"GH_TOKEN\": \"{token}\",\n \"GH_HOST\": \"github.com\"\n }}\n}}\n" |
167 | | ) |
168 | 4 | } |
169 | | |
170 | | /// Return `true` when every byte of `token` is in the GitHub token |
171 | | /// alphabet `[A-Za-z0-9_]`. |
172 | | /// |
173 | | /// Enforcing this invariant is what lets [`build_settings_body`] |
174 | | /// embed the token directly into a JSON template without escaping - |
175 | | /// none of the characters in this alphabet need JSON escaping, so a |
176 | | /// token that passes this check cannot break out of its string |
177 | | /// literal nor inject additional keys. Fine-grained PATs, classic |
178 | | /// PATs, and OAuth tokens all share the same alphabet, so the same |
179 | | /// check applies to every accepted token shape. |
180 | | /// |
181 | | /// # Arguments |
182 | | /// |
183 | | /// * `token` - Trimmed token to validate. |
184 | | /// |
185 | | /// # Returns |
186 | | /// |
187 | | /// `true` when `token` is non-empty and contains only the allowed |
188 | | /// characters; `false` otherwise. |
189 | 5 | fn is_in_token_alphabet(token: &str) -> bool { |
190 | 5 | !token.is_empty() |
191 | 5 | && token |
192 | 5 | .bytes() |
193 | 123 | .all5 (|b| b.is_ascii_alphanumeric() || b == b'_'9 ) |
194 | 5 | } |
195 | | |
196 | | /// Recognized GitHub token shapes. |
197 | | #[derive(Clone, Copy)] |
198 | | enum TokenKind { |
199 | | FineGrained, |
200 | | Classic, |
201 | | OAuth, |
202 | | } |
203 | | |
204 | | impl TokenKind { |
205 | | /// Identify the token shape from its prefix. |
206 | | /// |
207 | | /// # Arguments |
208 | | /// |
209 | | /// * `token` - Trimmed token contents. |
210 | | /// |
211 | | /// # Returns |
212 | | /// |
213 | | /// `Some(kind)` when the token starts with a recognized prefix, |
214 | | /// `None` otherwise. |
215 | 6 | fn classify(token: &str) -> Option<Self> { |
216 | 6 | if token.starts_with(FINE_GRAINED_PREFIX) { |
217 | 3 | Some(Self::FineGrained) |
218 | 3 | } else if token.starts_with(CLASSIC_PREFIX) { |
219 | 1 | Some(Self::Classic) |
220 | 2 | } else if token.starts_with(OAUTH_PREFIX) { |
221 | 1 | Some(Self::OAuth) |
222 | | } else { |
223 | 1 | None |
224 | | } |
225 | 6 | } |
226 | | } |
227 | | |
228 | | /// Resolve the source checkout directory. |
229 | | /// |
230 | | /// Paseo passes `PASEO_SOURCE_CHECKOUT_PATH` into `worktree.setup` |
231 | | /// subprocesses. When the variable is missing - for example when the |
232 | | /// subcommand is invoked manually - fall back to the current |
233 | | /// directory so running it from the repo root behaves intuitively. |
234 | | /// |
235 | | /// # Arguments |
236 | | /// |
237 | | /// * `system` - Injected I/O provider. |
238 | | /// |
239 | | /// # Returns |
240 | | /// |
241 | | /// The source checkout path. |
242 | | /// |
243 | | /// # Errors |
244 | | /// |
245 | | /// Returns an error only when the fallback `current_dir` lookup |
246 | | /// fails. |
247 | 9 | fn resolve_source_checkout<S: InjectAgentTokenSystem>(system: &S) -> Result<PathBuf> { |
248 | 9 | if let Some(path8 ) = system.env_var("PASEO_SOURCE_CHECKOUT_PATH") { |
249 | 8 | return Ok(PathBuf::from(path)); |
250 | 1 | } |
251 | 1 | system.current_dir() |
252 | 9 | } |
253 | | |
254 | | /// Inject the contributor's GitHub token into the current worktree's |
255 | | /// Claude Code settings. |
256 | | /// |
257 | | /// The token is read from `<source-checkout>/.paseo/gh-token`. A |
258 | | /// missing token file is treated as an opt-out: the function logs a |
259 | | /// notice and returns `Ok(())` so worktree creation is not blocked |
260 | | /// for contributors who have not set a token up yet. Fine-grained |
261 | | /// PATs are written silently; classic and OAuth tokens are written |
262 | | /// but trigger a warning log line recommending fine-grained PATs. |
263 | | /// |
264 | | /// # Arguments |
265 | | /// |
266 | | /// * `system` - Injected I/O provider. |
267 | | /// |
268 | | /// # Returns |
269 | | /// |
270 | | /// `Ok(())` on success or when the token file is absent. |
271 | | /// |
272 | | /// # Errors |
273 | | /// |
274 | | /// Returns an error when a token file exists but does not start with |
275 | | /// one of the recognized prefixes ([`FINE_GRAINED_PREFIX`], |
276 | | /// [`CLASSIC_PREFIX`], [`OAUTH_PREFIX`]), when its trimmed contents |
277 | | /// fall outside the token alphabet (see [`is_in_token_alphabet`]), |
278 | | /// or when the settings file cannot be written. |
279 | 9 | pub fn inject_agent_token<S: InjectAgentTokenSystem>(system: &S) -> Result<()> { |
280 | 9 | let source = resolve_source_checkout(system)?0 ; |
281 | 9 | let token_file = source.join(TOKEN_FILE_REL_PATH); |
282 | | |
283 | 9 | let Some(raw7 ) = system.read_token_file(&token_file)?0 else { |
284 | 2 | log::info!( |
285 | | "paseo agent GitHub auth: no {} found; agents will use your existing gh login. See CONTRIBUTING.md.", |
286 | 2 | token_file.display() |
287 | | ); |
288 | 2 | return Ok(()); |
289 | | }; |
290 | | |
291 | 7 | let token = raw.trim(); |
292 | 7 | if token.is_empty() { |
293 | 1 | bail!( |
294 | | "{} is empty; expected a GitHub token starting with `{}` (recommended), `{}`, or `{}`. See CONTRIBUTING.md.", |
295 | 1 | token_file.display(), |
296 | | FINE_GRAINED_PREFIX, |
297 | | CLASSIC_PREFIX, |
298 | | OAUTH_PREFIX, |
299 | | ); |
300 | 6 | } |
301 | 6 | let Some(kind5 ) = TokenKind::classify(token) else { |
302 | 1 | bail!( |
303 | | "{} must contain a GitHub token starting with `{}` (recommended), `{}`, or `{}`. See CONTRIBUTING.md.", |
304 | 1 | token_file.display(), |
305 | | FINE_GRAINED_PREFIX, |
306 | | CLASSIC_PREFIX, |
307 | | OAUTH_PREFIX, |
308 | | ); |
309 | | }; |
310 | 5 | if !is_in_token_alphabet(token) { |
311 | 1 | bail!( |
312 | | "{} contains characters outside the GitHub token alphabet ([A-Za-z0-9_]); refusing to embed it in settings. See CONTRIBUTING.md.", |
313 | 1 | token_file.display() |
314 | | ); |
315 | 4 | } |
316 | | |
317 | 4 | let cwd = system.current_dir()?0 ; |
318 | 4 | let settings_path = cwd.join(SETTINGS_FILE_REL_PATH); |
319 | 4 | let body = build_settings_body(token); |
320 | 4 | system.write_settings(&settings_path, &body)?0 ; |
321 | | |
322 | 4 | match kind { |
323 | | TokenKind::FineGrained => { |
324 | 2 | log::info!( |
325 | | "paseo agent GitHub auth: wrote {} from {} (scoped PAT)", |
326 | 2 | settings_path.display(), |
327 | 2 | token_file.display(), |
328 | | ); |
329 | | } |
330 | | TokenKind::Classic => { |
331 | 1 | log::warn!( |
332 | | "paseo agent GitHub auth: detected a classic token in {}; wrote {} but fine-grained PATs (prefix `{}`) are recommended because they can be restricted to specific repositories and permissions, while classic tokens cannot. See CONTRIBUTING.md.", |
333 | 1 | token_file.display(), |
334 | 1 | settings_path.display(), |
335 | | FINE_GRAINED_PREFIX, |
336 | | ); |
337 | | } |
338 | | TokenKind::OAuth => { |
339 | 1 | log::warn!( |
340 | | "paseo agent GitHub auth: detected an OAuth token in {}; wrote {} but fine-grained PATs (prefix `{}`) are recommended because they can be restricted to specific repositories and permissions, while OAuth tokens cannot. See CONTRIBUTING.md.", |
341 | 1 | token_file.display(), |
342 | 1 | settings_path.display(), |
343 | | FINE_GRAINED_PREFIX, |
344 | | ); |
345 | | } |
346 | | } |
347 | | |
348 | 4 | Ok(()) |
349 | 9 | } |
350 | | |
351 | | #[cfg(test)] |
352 | | #[path = "tests/test_inject_agent_token.rs"] |
353 | | mod tests; |